8-2 创建菜单接口&数据库查询实现(嵌套数据)
本节实现菜单的创建接口,核心难点在于处理嵌套树形数据——父级菜单创建后获取 id,再将 parentId 赋给子菜单,使用递归方法完成多层嵌套创建。
一、CreateMenuDto 设计
菜单创建 DTO 需要支持嵌套的 Meta 信息和子菜单数据:
// dto/create-menu.dto.ts
import {
IsString, IsOptional, IsInt, IsBoolean,
ValidateNested, IsNumber,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateMenuMetaDto {
@IsOptional()
@IsNumber()
id?: number;
@IsString()
title: string;
@IsOptional()
@IsString()
icon?: string;
@IsOptional()
@IsInt()
order?: number;
@IsOptional()
@IsBoolean()
hideMenu?: boolean;
@IsOptional()
@IsBoolean()
disabled?: boolean;
}
export class CreateMenuDto {
@IsString()
name: string;
@IsString()
path: string;
@IsOptional()
@IsString()
component?: string;
@IsOptional()
@IsString()
redirect?: string;
@IsOptional()
@IsString()
fullPath?: string;
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@ValidateNested()
@Type(() => CreateMenuMetaDto)
meta?: CreateMenuMetaDto;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateMenuDto)
children?: CreateMenuDto[];
}
typescript
要点:
children的类型就是CreateMenuDto[]自身,形成递归类型定义@ValidateNested({ each: true })+@Type()确保嵌套对象被正确验证component、redirect等字段设为@IsOptional(),因为子菜单可能不需要这些属性
二、递归创建方法
核心挑战:先创建父菜单获取 id,再创建子菜单。使用递归方法 createNested 处理:
// menu.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client/generated/postgresql';
import { CreateMenuDto } from './dto/create-menu.dto';
@Injectable()
export class MenuService {
constructor(private prisma: PrismaClient) {}
async create(dto: CreateMenuDto) {
const data = await this.createNested(dto);
// 创建后使用 findUnique 重新查询,确保数据结构完整
return this.prisma.menu.findUnique({
where: { id: data.id },
include: {
meta: true,
children: {
include: {
meta: true,
children: true, // 最多查两层嵌套
},
},
},
});
}
private async createNested(dto: CreateMenuDto, parentId?: number) {
const { meta, children, ...restData } = dto;
// 1. 创建父级菜单(含 meta)
const parent = await this.prisma.menu.create({
data: {
...restData,
...(parentId && { parentId }), // 有 parentId 时添加
...(meta && {
meta: { create: meta }, // 嵌套创建 Meta
}),
},
});
// 2. 递归创建子菜单
if (children && children.length > 0) {
const childrenData = await Promise.all(
children.map((child) => this.createNested(child, parent.id)),
);
parent.children = childrenData;
}
return parent;
}
}
typescript
三、执行流程图解
CreateMenuDto (父菜单)
├── meta: { title, icon, order }
├── name, path, component...
└── children: [
├── CreateMenuDto (子菜单 1)
│ ├── meta: { title, icon }
│ ├── name, path...
│ └── children: [...] ← 递归继续
├── CreateMenuDto (子菜单 2)
└── CreateMenuDto (子菜单 3)
]
执行顺序:
1. createNested(parentDto)
→ prisma.menu.create({ ...restData, meta: { create: meta } })
→ 获得 parentId
2. Promise.all([
createNested(child1, parentId),
createNested(child2, parentId),
createNested(child3, parentId),
])
3. findUnique + include → 返回完整的嵌套结构
text
四、Prisma Import 注意事项
导入 PrismaClient 时必须从 Schema 中指定的 output 路径导入:
// 正确:从 schema.prisma 中最新的 output 路径导入
import { PrismaClient } from '@prisma/client/generated/postgresql';
// 错误:从旧路径导入,可能缺少新创建的 Model
import { PrismaClient } from '@prisma/client';
typescript
当 Prisma Schema 发生变更(新增 Model 或字段)时,务必执行 npx prisma db push 重新生成 Client,并确认导入路径与 schema.prisma 中的 output 配置一致。
五、嵌套查询的性能优化
方案对比
| 方案 | 实现方式 | 性能 | 适用场景 |
|---|---|---|---|
| 多层 include | include: { children: { include: { children: ... } } } | 差(层级越深越慢) | 嵌套层级固定且浅(1-2层) |
| 全量查询+JS 递归 | 查询所有菜单,JS 端构建树形结构 | 优(CPU 远快于 DB) | 菜单数据量小(管理后台通常如此) |
| MongoDB 嵌套文档 | 天然支持嵌套存储 | 优 | 使用文档数据库的项目 |
推荐方案:全量查询 + JS 递归
菜单数据量通常不大(管理后台的菜单数量有限),一次性查询所有菜单后用 JS 递归构建树形结构,性能远优于数据库多层 include:
// 性能更优的树形查询方案
async findAllTree() {
const allMenus = await this.prisma.menu.findMany({
include: { meta: true },
});
// JS 递归构建树形结构
const buildTree = (parentId: number | null): any[] => {
return allMenus
.filter((menu) => menu.parentId === parentId)
.map((menu) => ({
...menu,
children: buildTree(menu.id),
}));
};
return buildTree(null); // null 表示一级菜单
}
typescript
六、Schema 迭代与字段补充
开发过程中发现遗漏字段时,需要同步更新三处:
# 1. 在 schema.prisma 中添加字段(如 label)
# 2. 同步数据库
npx prisma db push
# 3. 在 CreateMenuDto 中添加对应字段
# 4. 重启调试进程
bash
七、测试数据示例
{
"name": "content",
"path": "/content",
"component": "LAYOUT",
"meta": {
"title": "内容管理",
"icon": "ant-design:read-outlined",
"order": 2
},
"children": [
{
"name": "article",
"path": "article",
"component": "/content/article/index",
"meta": {
"title": "文章管理",
"icon": "ant-design:file-text-outlined"
}
},
{
"name": "category",
"path": "category",
"component": "/content/category/index",
"meta": {
"title": "分类管理",
"icon": "ant-design:folder-outlined"
}
}
]
}
json
八、数据库验证
创建完成后,在 Prisma Studio 中验证:
- menus 表:一级菜单的
parentId为 null,子菜单的parentId指向父菜单 ID - menu_metas 表:每条 Meta 记录的
menuId对应关联的菜单 ID - 响应数据:使用
findUnique+include确保返回完整的嵌套结构
九、总结
| 知识点 | 说明 |
|---|---|
| 递归创建 | createNested 方法递归处理父子菜单的创建 |
| Prisma include | 最多查两层嵌套,避免深层 include 影响性能 |
| 全量查询+JS 递归 | 菜单数据量小时,比数据库多层 include 性能更优 |
| PrismaClient 导入 | 必须从 schema.prisma 指定的 output 路径导入 |
| Schema 迭代 | 新增字段后需 db push + 更新 DTO + 重启服务 |
↑